IOweb/node_modules/satteri/dist/lazy-child-resolver.js
2026-07-03 15:07:38 -05:00

95 lines
4.4 KiB
JavaScript

import { serializeHandle } from "#binding";
/** Rebuild count per handle: bumped whenever a command buffer lands and
* renumbers the arena, invalidating ids captured before it. */
const HANDLE_EPOCHS = new WeakMap();
/** Record that `handle`'s arena was rebuilt. Resolvers created before the bump
* refuse to take a fresh snapshot afterwards (their ids are stale). */
export function markHandleMutated(handle) {
HANDLE_EPOCHS.set(handle, (HANDLE_EPOCHS.get(handle) ?? 0) + 1);
}
/** Arena sentinel in the node struct's parent field: the node has no parent. */
const NO_PARENT = 0xffffffff;
/**
* Lazy node materializer for the walk paths: serializes the handle's arena
* once, on the first stub materialization, then materializes nodes from that
* snapshot. Subclasses supply reader construction and per-node materialization
* so the hot path stays free of per-node closures.
*/
export class LazyChildResolver {
#handle;
#reader = null;
#sealed = false;
#epoch;
constructor(handle) {
this.#handle = handle;
this.#epoch = HANDLE_EPOCHS.get(handle) ?? 0;
}
/**
* Mark the visitor pass over. Child ids were captured at match time; once
* the pass's mutations land the arena is rebuilt and ids renumbered, so a
* later snapshot would map those stale ids onto the wrong nodes (or out of
* range). Failing loudly here beats silently wrong children.
*/
seal() {
this.#sealed = true;
}
/** Guards the `.children` getters: a first read after the pass is refused
* outright, even when no mutation landed — match-time ids cannot be trusted
* once the plugin no longer controls the tree. */
assertUnsealed() {
if (this.#sealed) {
throw new Error("Cannot read node content: this node was retained past its visitor pass " +
"and the tree may have changed since; read it inside the visitor.");
}
}
#ensureReader() {
if (this.#reader === null) {
// A node id proves the tree was read in-pass, so a deferred snapshot is
// still faithful as long as no command buffer rebuilt the arena since
// match time (ids renumber on rebuild). An existing reader is always
// safe: the snapshot is an immutable pre-mutation copy.
if ((HANDLE_EPOCHS.get(this.#handle) ?? 0) !== this.#epoch) {
throw new Error("Cannot read node content: this node was retained past its visitor pass " +
"and the tree has changed since; read it inside the visitor.");
}
// The serialized buffer already carries each node's `data` blob (read
// eagerly by the materializer), and the arena isn't mutated mid-visit —
// so no separate lazy NAPI fetch is needed. This also keeps walk-path
// children consistent with the fully materialized tree (no `data` key
// when a node has none).
this.#reader = this.createReader(serializeHandle(this.#handle));
}
return this.#reader;
}
/** Materialize one node for a child stub's first real-field read. */
materializeOne(nodeId) {
return this.materializeNode(this.#ensureReader(), nodeId);
}
/** Arena id of `nodeId`'s parent in the pass snapshot, or undefined at the root. */
parentIdOf(nodeId) {
this.assertUnsealed();
const parentId = this.readParentId(this.#ensureReader(), nodeId);
return parentId === NO_PARENT ? undefined : parentId;
}
/** Per-parent child-id→index maps, built lazily: null until a plugin calls
* `indexInParent` (most never do). Cache-safe because the snapshot is immutable. */
#childIndexByParent = null;
/** Index of `nodeId` within its parent's children in the pass snapshot,
* or undefined at the root. */
indexInParent(nodeId) {
this.assertUnsealed();
const reader = this.#ensureReader();
const parentId = this.readParentId(reader, nodeId);
if (parentId === NO_PARENT)
return undefined;
const byParent = (this.#childIndexByParent ??= new Map());
let indexById = byParent.get(parentId);
if (indexById === undefined) {
const map = new Map();
this.readChildIds(reader, parentId).forEach((id, i) => map.set(id, i));
byParent.set(parentId, map);
indexById = map;
}
return indexById.get(nodeId);
}
}