@optimystic/db-core 0.24.2 → 0.25.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.
- package/dist/src/blocks/helpers.d.ts +5 -0
- package/dist/src/blocks/helpers.d.ts.map +1 -1
- package/dist/src/blocks/helpers.js +12 -0
- package/dist/src/blocks/helpers.js.map +1 -1
- package/dist/src/cluster/membership.d.ts +7 -0
- package/dist/src/cluster/membership.d.ts.map +1 -1
- package/dist/src/cluster/membership.js +12 -8
- package/dist/src/cluster/membership.js.map +1 -1
- package/dist/src/cluster/structs.d.ts +27 -1
- package/dist/src/cluster/structs.d.ts.map +1 -1
- package/dist/src/cluster/structs.js.map +1 -1
- package/dist/src/collection/action.d.ts +14 -0
- package/dist/src/collection/action.d.ts.map +1 -1
- package/dist/src/collection/action.js +16 -1
- package/dist/src/collection/action.js.map +1 -1
- package/dist/src/collection/collection.d.ts +273 -4
- package/dist/src/collection/collection.d.ts.map +1 -1
- package/dist/src/collection/collection.js +427 -30
- package/dist/src/collection/collection.js.map +1 -1
- package/dist/src/collections/tree/tree.d.ts +16 -1
- package/dist/src/collections/tree/tree.d.ts.map +1 -1
- package/dist/src/collections/tree/tree.js +19 -0
- package/dist/src/collections/tree/tree.js.map +1 -1
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +1 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/network/i-repo.d.ts +11 -1
- package/dist/src/network/i-repo.d.ts.map +1 -1
- package/dist/src/network/stale-failure.d.ts +21 -0
- package/dist/src/network/stale-failure.d.ts.map +1 -1
- package/dist/src/network/stale-failure.js +22 -0
- package/dist/src/network/stale-failure.js.map +1 -1
- package/dist/src/network/struct.d.ts +66 -21
- package/dist/src/network/struct.d.ts.map +1 -1
- package/dist/src/network/struct.js.map +1 -1
- package/dist/src/testing/test-transactor.d.ts +22 -0
- package/dist/src/testing/test-transactor.d.ts.map +1 -1
- package/dist/src/testing/test-transactor.js +44 -5
- package/dist/src/testing/test-transactor.js.map +1 -1
- package/dist/src/transaction/coordinator.d.ts +20 -0
- package/dist/src/transaction/coordinator.d.ts.map +1 -1
- package/dist/src/transaction/coordinator.js +256 -113
- package/dist/src/transaction/coordinator.js.map +1 -1
- package/dist/src/transaction/operations-hash.d.ts +1 -1
- package/dist/src/transaction/operations-hash.js +1 -1
- package/dist/src/transaction/transaction.d.ts +4 -2
- package/dist/src/transaction/transaction.d.ts.map +1 -1
- package/dist/src/transactor/network-transactor.d.ts +21 -1
- package/dist/src/transactor/network-transactor.d.ts.map +1 -1
- package/dist/src/transactor/network-transactor.js +122 -29
- package/dist/src/transactor/network-transactor.js.map +1 -1
- package/dist/src/transactor/transactor-source.d.ts +11 -5
- package/dist/src/transactor/transactor-source.d.ts.map +1 -1
- package/dist/src/transactor/transactor-source.js +16 -8
- package/dist/src/transactor/transactor-source.js.map +1 -1
- package/dist/src/transform/cache-source.d.ts +13 -0
- package/dist/src/transform/cache-source.d.ts.map +1 -1
- package/dist/src/transform/cache-source.js +18 -0
- package/dist/src/transform/cache-source.js.map +1 -1
- package/dist/src/transform/digest.d.ts +18 -0
- package/dist/src/transform/digest.d.ts.map +1 -0
- package/dist/src/transform/digest.js +65 -0
- package/dist/src/transform/digest.js.map +1 -0
- package/dist/src/transform/index.d.ts +1 -0
- package/dist/src/transform/index.d.ts.map +1 -1
- package/dist/src/transform/index.js +1 -0
- package/dist/src/transform/index.js.map +1 -1
- package/dist/src/transform/tracker.d.ts +16 -0
- package/dist/src/transform/tracker.d.ts.map +1 -1
- package/dist/src/transform/tracker.js +40 -1
- package/dist/src/transform/tracker.js.map +1 -1
- package/dist/src/utility/canonical-json.d.ts +11 -0
- package/dist/src/utility/canonical-json.d.ts.map +1 -0
- package/dist/src/utility/canonical-json.js +15 -0
- package/dist/src/utility/canonical-json.js.map +1 -0
- package/dist/src/utility/lru-map.d.ts +2 -0
- package/dist/src/utility/lru-map.d.ts.map +1 -1
- package/dist/src/utility/lru-map.js +4 -0
- package/dist/src/utility/lru-map.js.map +1 -1
- package/package.json +2 -1
- package/src/blocks/helpers.ts +26 -13
- package/src/cluster/membership.ts +87 -85
- package/src/cluster/structs.ts +28 -1
- package/src/collection/action.ts +17 -0
- package/src/collection/collection.ts +1129 -688
- package/src/collections/tree/tree.ts +341 -320
- package/src/index.ts +24 -23
- package/src/network/i-repo.ts +59 -46
- package/src/network/stale-failure.ts +67 -43
- package/src/network/struct.ts +332 -270
- package/src/testing/test-transactor.ts +680 -638
- package/src/transaction/coordinator.ts +1266 -1110
- package/src/transaction/operations-hash.ts +1 -1
- package/src/transaction/transaction.ts +4 -2
- package/src/transactor/network-transactor.ts +123 -32
- package/src/transactor/transactor-source.ts +17 -9
- package/src/transform/cache-source.ts +20 -0
- package/src/transform/digest.ts +72 -0
- package/src/transform/index.ts +1 -0
- package/src/transform/tracker.ts +42 -1
- package/src/utility/canonical-json.ts +16 -0
- 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
|
-
*
|
|
257
|
-
*
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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
|
+
}
|