// editor.ts import type { HsonNode, JsonValue, Primitive } from "../../core/types.js"; import { is_Node } from "../../core/node-guards.js"; import type { LivePathPart, LivePath, LiveMapEditResult } from "../../types/livemap.types.js"; import { _HSON_, ARR_TAG, II_TAG, OBJ_TAG, STR_TAG, VAL_TAG } from "../../core/constants.js"; import { CREATE_NODE } from "../../core/factories.js"; import { format_live_path } from "./livemap.path.js"; import { clone_node } from "../../core/clone-node.js"; import { admit_projected_value } from "../../core/projected-value-admission.js"; import { materialize_projected_value } from "../../core/projected-value-materialization.js"; import { optional_ordered_projected_value_equal, ordered_projected_value_equal, type OrderedProjectedValue, } from "../../core/ordered-projected-value.js"; import { is_projected_value_hson_node, projected_array_item_to_hson_node, projected_object_property_to_hson_node, projected_value_from_hson_node, projected_value_to_hson_root, } from "../../core/projected-value-graph.js"; /** * Internal physical witness for one data path. This intentionally stays * out of the public LiveMap surface: consumers should continue to use logical * data paths, while internal bridges can reuse the editor's canonical * wrapper traversal. */ export type InternalProjectedHsonLocation = Readonly<{ wrapperPath: readonly number[]; valuePath: readonly number[]; scalarValuePath?: readonly number[]; }>; /** * Parent resolution for a projected LiveMap path. * * `parent` is the value node that owns the final path segment. For the first * mutation slice this must be an object value node. `key` is the final projected * path segment to read or write under that parent. */ type ResolvedParent = Readonly<{ parent: HsonNode; key: LivePathPart; }>; /** * Clone a LiveMap Hson root for staged editor preflight or detached reads. * * Batch validation uses this to prove every editor operation can apply before * mutating the live graph, and `LiveMap.root()` uses it for detached canonical * snapshots. It preserves attrs/meta/content without sharing graph-owned * object identity, including structured style values. */ export function clone_live_root(root: HsonNode): HsonNode { return clone_hson_node(root); } /** * Read the projected JSON value at a LiveMap path. * * This is the editor's read entry point. It resolves the data path through * Hson wrapper nodes, unwraps the value payload, and converts that value node * back into ordinary JSON-ish data. */ export function snap_live_path(root: HsonNode, path: LivePath): JsonValue | undefined { const valueNode = resolve_value_node(root, path); return valueNode === undefined ? undefined : node_to_json_value(valueNode); } /** Read one data path as the immutable internal carrier. */ export function project_live_path(root: HsonNode, path: LivePath): OrderedProjectedValue | undefined { const valueNode = resolve_value_node(root, path); return valueNode === undefined ? undefined : projected_value_from_hson_node(valueNode); } /** * Resolve a data path to the value payload node at that path. * * Example: for `["user", "name"]`, this returns the `_hson_str` or `_hson_val` * payload inside the `name` property wrapper, not the `name` wrapper itself. */ export function resolve_value_node(root: HsonNode, path: LivePath): HsonNode | undefined { const wrapper = resolve_wrapper_node(root, path); if (wrapper === undefined) return undefined; return unwrap_value_payload(wrapper); } /** * Resolve one data path through the same wrapper rules as the editor, * retaining physical canonical paths for internal location/provenance work. */ export function resolve_projected_hson_location( root: HsonNode, path: LivePath, ): InternalProjectedHsonLocation | undefined { let current = root; let wrapperPath: readonly number[] = []; for (const part of path) { const payload = unwrap_value_payload(current); if (payload === undefined) return undefined; const childIndex = child_wrapper_index(payload, part); if (childIndex === undefined) return undefined; wrapperPath = [ ...wrapperPath, ...payload_path_from_wrapper(current), ...transparent_value_path(payload), childIndex, ]; const child = payload.$_content[childIndex]; if (!is_Node(child)) return undefined; current = child; } const payload = unwrap_value_payload(current); if (payload === undefined) return undefined; const valuePath = [ ...wrapperPath, ...payload_path_from_wrapper(current), ...transparent_value_path(payload), ]; const value = value_at_transparent_path(payload); if (value === undefined) return undefined; const scalarValuePath = value.$_tag === STR_TAG || value.$_tag === VAL_TAG ? [...valuePath, 0] : undefined; return Object.freeze({ wrapperPath: Object.freeze(wrapperPath), valuePath: Object.freeze(valuePath), ...(scalarValuePath === undefined ? {} : { scalarValuePath: Object.freeze(scalarValuePath) }), }); } /** * Resolve a data path to the wrapper node at that path. * * For object properties this is the user-key wrapper, such as a node with * `$_tag === "name"`. For array items this is the `_hson_ii` wrapper. Keeping * this separate from `resolve_value_node()` is important because mutation often * needs to replace or remove the wrapper, while reads usually want the payload. */ export function resolve_wrapper_node(root: HsonNode, path: LivePath): HsonNode | undefined { let current: HsonNode | undefined = root; for (const part of path) { const valueNode = unwrap_value_payload(current); if (valueNode === undefined) return undefined; current = find_child_wrapper(valueNode, part); if (current === undefined) return undefined; } return current; } /** * Resolve the parent value node for a data path. * * This is the mutation-oriented resolver. Given `["user", "name"]`, it returns * the value node for `["user"]` plus the final key `"name"`. */ export function resolve_parent_node(root: HsonNode, path: LivePath): ResolvedParent | undefined { if (path.length === 0) return undefined; const parentPath = path.slice(0, -1); const key = path[path.length - 1]; if (key === undefined) return undefined; const parent = resolve_value_node(root, parentPath); if (parent === undefined) return undefined; return { parent, key }; } /** * Set a data object-property path to a JSON value. * * This is the first mutation slice and is intentionally narrow: * - it supports replacing or adding properties on an already-resolved object * - it does not replace the root yet * - it can replace existing array indexes * - it does not auto-create missing parent containers yet * * The editor path setter only replaces existing array indexes. Higher-level array handle helpers perform append/insert/remove by replacing the scoped array value. * The editor returns raw edit information. Core wraps that into commit/op form. */ export function set_live_path(root: HsonNode, path: LivePath, value: JsonValue): LiveMapEditResult { return materialize_projected_edit(set_live_path_from_projected(root, path, admit_projected_value(value))); } type LiveMapProjectedEditResult = Readonly<{ changed: boolean; prev: OrderedProjectedValue | undefined; next: OrderedProjectedValue | undefined; }>; /** Apply one already-admitted carrier value without rereading JavaScript input. */ export function set_live_path_from_projected( root: HsonNode, path: LivePath, nextCarrier: OrderedProjectedValue, ): LiveMapProjectedEditResult { if (path.length === 0) { throw new Error("LiveMap editor cannot replace the root node yet."); } const prevNode = resolve_value_node(root, path); const prevCarrier = prevNode === undefined ? undefined : projected_value_from_hson_node(prevNode); const resolved = resolve_parent_node(root, path); if (resolved === undefined) { throw new Error(`LiveMap editor could not resolve parent path: ${format_live_path(path.slice(0, -1))}`); } write_child_value(resolved.parent, resolved.key, nextCarrier, path); return { changed: !optional_ordered_projected_value_equal(prevCarrier, nextCarrier), prev: prevCarrier, next: nextCarrier, }; } /** * Delete a data object-property path. * * This first delete slice is intentionally narrow: * - it can remove object properties from an already-resolved object * - missing object properties are unchanged, not errors * - it does not replace or delete the root * - it does not delete array indexes directly; higher-level array handle helpers remove/reorder by replacing the scoped array value * - it does not auto-remove empty parent containers * * The editor returns raw edit information. Core wraps that into commit/op form. */ export function delete_live_path(root: HsonNode, path: LivePath): LiveMapEditResult { if (path.length === 0) { throw new Error("LiveMap editor cannot delete the root node yet."); } const prev = snap_live_path(root, path); if (prev === undefined) { throw new Error(`LiveMap delete path does not resolve: ${format_live_path(path)}`); } const resolved = resolve_parent_node(root, path); if (resolved === undefined) { throw new Error(`LiveMap editor could not resolve parent path: ${format_live_path(path.slice(0, -1))}`); } delete_child_value(resolved.parent, resolved.key, path); const next = snap_live_path(root, path); return { changed: true, prev, next, }; } /** * Replace the projected LiveMap root value in place. * * Core owns a stable root node reference that path handles and subscriptions * close over, so root replacement must overwrite the existing root node rather * than returning a new one. The replacement value is converted through the same * editor-local JSON projection used by path writes. */ export function replace_live_root(root: HsonNode, value: JsonValue): LiveMapEditResult { return materialize_projected_edit(replace_live_root_from_projected(root, admit_projected_value(value))); } /** Replace the data root from one already-admitted carrier. */ export function replace_live_root_from_projected( root: HsonNode, nextCarrier: OrderedProjectedValue, ): LiveMapProjectedEditResult { const prevNode = resolve_value_node(root, []); const prevCarrier = prevNode === undefined ? undefined : projected_value_from_hson_node(prevNode); const nextRoot = projected_value_to_hson_root(nextCarrier); overwrite_hson_node(root, nextRoot); return { changed: !optional_ordered_projected_value_equal(prevCarrier, nextCarrier), prev: prevCarrier, next: nextCarrier, }; } /** * Replace the projected LiveMap value at any endpoint path. * * Root replacement preserves the root node object identity. Non-root * replacement swaps the endpoint wrapper under its existing parent, so * unspecified object siblings inside the replaced endpoint are removed while * outer/root identity remains stable. */ export function replace_live_path(root: HsonNode, path: LivePath, value: JsonValue): LiveMapEditResult { return materialize_projected_edit(replace_live_path_from_projected(root, path, admit_projected_value(value))); } /** Replace one resolved path from one already-admitted carrier. */ export function replace_live_path_from_projected( root: HsonNode, path: LivePath, nextCarrier: OrderedProjectedValue, ): LiveMapProjectedEditResult { if (path.length === 0) return replace_live_root_from_projected(root, nextCarrier); const prevNode = resolve_value_node(root, path); if (prevNode === undefined) { throw new Error(`LiveMap replace path does not resolve: ${format_live_path(path)}`); } const prevCarrier = projected_value_from_hson_node(prevNode); const resolved = resolve_parent_node(root, path); if (resolved === undefined) { throw new Error(`LiveMap editor could not resolve parent path: ${format_live_path(path.slice(0, -1))}`); } write_child_value(resolved.parent, resolved.key, nextCarrier, path); return { changed: !ordered_projected_value_equal(prevCarrier, nextCarrier), prev: prevCarrier, next: nextCarrier, }; } /** * Write one projected child value under an already-resolved parent value node. * * Objects support property add/replace by string key. Arrays support replacing * an existing numeric index only. Array append/insert/remove operations are * handled by path-handle array helpers that replace the scoped array value. */ function write_child_value(parent: HsonNode, key: LivePathPart, value: OrderedProjectedValue, path: LivePath): void { if (parent.$_tag === OBJ_TAG && typeof key === "string") { write_object_property(parent, key, value); return; } if (parent.$_tag === ARR_TAG && typeof key === "number") { write_array_index(parent, key, value, path); return; } throw new Error(`LiveMap editor cannot set this path yet: ${format_live_path(path)}`); } /** * Add or replace an object property wrapper. * * Missing object properties are allowed because object shape can expand without * needing list insertion semantics. */ function write_object_property(parent: HsonNode, key: string, value: OrderedProjectedValue): void { const nextWrapper = projected_object_property_to_hson_node(key, value); const existingIndex = parent.$_content.findIndex((child) => is_Node(child) && child.$_tag === key); if (existingIndex === -1) { parent.$_content.push(nextWrapper); } else { parent.$_content[existingIndex] = nextWrapper; } } /** * Replace an existing array item wrapper by numeric index. * * Missing indexes throw for now. That keeps this first array mutation slice to * deterministic replacement and avoids deciding append/sparse-array behavior. */ function write_array_index(parent: HsonNode, index: number, value: OrderedProjectedValue, path: LivePath): void { if (!Number.isInteger(index) || index < 0 || index >= parent.$_content.length) { throw new Error(`LiveMap editor cannot set invalid array index: ${format_live_path(path)}`); } const existing = parent.$_content[index]; if (!is_Node(existing) || existing.$_tag !== II_TAG) { throw new Error(`LiveMap editor cannot append or insert array indexes yet: ${format_live_path(path)}`); } parent.$_content[index] = projected_array_item_to_hson_node(index, value); } /** * Delete one projected child value under an already-resolved parent value node. * * Objects support property deletion by string key. Arrays are not deleted here; * path-handle array helpers remove/reorder by replacing the scoped array value. */ function delete_child_value(parent: HsonNode, key: LivePathPart, path: LivePath): void { if (parent.$_tag === OBJ_TAG && typeof key === "string") { delete_object_property(parent, key); return; } if (parent.$_tag === ARR_TAG && typeof key === "number") { throw new Error(`LiveMap editor cannot delete array indexes yet: ${format_live_path(path)}`); } throw new Error(`LiveMap editor cannot delete this path yet: ${format_live_path(path)}`); } /** * Remove an object property wrapper when it exists. * * Missing properties are no-ops so callers can treat delete as idempotent. */ function delete_object_property(parent: HsonNode, key: string): void { const existingIndex = parent.$_content.findIndex((child) => is_Node(child) && child.$_tag === key); if (existingIndex === -1) return; parent.$_content.splice(existingIndex, 1); } /** * Convert a Hson value node into projected JSON data. * * This is the data-map JSON projection, not a canonical document snapshot. * Unknown/user element nodes retain the historical object fallback for data * compatibility, but document LiveMaps do not expose this lossy reader. */ export function node_to_json_value(node: HsonNode): JsonValue { if (is_projected_value_hson_node(node)) { return materialize_projected_value(projected_value_from_hson_node(node)); } const transparentPayload = unwrap_transparent_object_payload(node); if (transparentPayload !== undefined) return node_to_json_value(transparentPayload); switch (node.$_tag) { case STR_TAG: return string_node_to_value(node); case VAL_TAG: return primitive_node_to_value(node); case OBJ_TAG: return object_node_to_value(node); case ARR_TAG: return array_node_to_value(node); default: return object_node_to_value(node); } } /** * Find the child wrapper for one data path segment. * * Object paths use string keys and find user-tag wrappers. Array paths use * numeric indexes and read the canonical `_hson_ii` physical position. */ function find_child_wrapper(parentValueNode: HsonNode, part: LivePathPart): HsonNode | undefined { if (parentValueNode.$_tag === OBJ_TAG && typeof part === "string") { return parentValueNode.$_content.find((child) => is_Node(child) && child.$_tag === part) as HsonNode | undefined; } if (parentValueNode.$_tag === ARR_TAG && typeof part === "number") { if (!Number.isInteger(part) || part < 0) return undefined; const child = parentValueNode.$_content[part]; return is_Node(child) && child.$_tag === II_TAG ? child : undefined; } return undefined; } function child_wrapper_index(parentValueNode: HsonNode, part: LivePathPart): number | undefined { if (parentValueNode.$_tag === OBJ_TAG && typeof part === "string") { const index = parentValueNode.$_content.findIndex((child) => is_Node(child) && child.$_tag === part); return index < 0 ? undefined : index; } if (parentValueNode.$_tag === ARR_TAG && typeof part === "number") { if (!Number.isInteger(part) || part < 0) return undefined; const child = parentValueNode.$_content[part]; return is_Node(child) && child.$_tag === II_TAG ? part : undefined; } return undefined; } /** * Convert a wrapper node to its value payload. * * Value nodes are already their own payload. User-key wrappers and `_hson_ii` * wrappers should contain one value node as their first child. */ function unwrap_value_payload(wrapper: HsonNode): HsonNode | undefined { if (is_value_node(wrapper)) return wrapper; const [first] = wrapper.$_content; if (!is_Node(first)) return undefined; return first; } function payload_path_from_wrapper(wrapper: HsonNode): readonly number[] { return is_value_node(wrapper) ? [] : [0]; } /** * Collapse canonical JSON value clusters such as `_hson_obj -> _hson_str`. * * The transform pipeline can wrap primitive and array JSON values in an * `_hson_obj` cluster under a user-key wrapper. LiveMap's projected reader * treats that cluster as transparent only when it contains exactly one * structural value child and no user-key children. */ function unwrap_transparent_object_payload(node: HsonNode): HsonNode | undefined { if (node.$_tag !== OBJ_TAG) return undefined; const nodeChildren = node.$_content.filter(is_Node); if (nodeChildren.length !== 1) return undefined; const onlyChild = nodeChildren[0]; if (onlyChild === undefined) return undefined; if (onlyChild.$_tag === STR_TAG || onlyChild.$_tag === VAL_TAG || onlyChild.$_tag === ARR_TAG) { return onlyChild; } return undefined; } /** Return the physical child indexes skipped by data scalar normalization. */ function transparent_value_path(node: HsonNode): readonly number[] { const indexes: number[] = []; let current = node; for (;;) { const transparent = unwrap_transparent_object_payload(current); if (transparent === undefined) return indexes; indexes.push(0); current = transparent; } } function value_at_transparent_path(node: HsonNode): HsonNode | undefined { let current = node; for (;;) { const transparent = unwrap_transparent_object_payload(current); if (transparent === undefined) return current; current = transparent; } } /** * Return true for Hson nodes that represent actual JSON-facing values. * * These are the nodes LiveMap reads as values rather than wrappers or structural * containers around values. */ function is_value_node(node: HsonNode): boolean { return node.$_tag === OBJ_TAG || node.$_tag === ARR_TAG || node.$_tag === STR_TAG || node.$_tag === VAL_TAG; } /** * Project a `_hson_str` node to a JavaScript string. * * Invalid or empty string nodes currently project as the empty string rather * than throwing; invariant enforcement belongs elsewhere. */ function string_node_to_value(node: HsonNode): string { const [first] = node.$_content; return typeof first === "string" ? first : ""; } /** * Project a `_hson_val` node to a non-string primitive. * * `_hson_val` is expected to carry number, boolean, or null. Malformed payloads * project as null for this early reader slice. */ function primitive_node_to_value(node: HsonNode): Primitive { const [first] = node.$_content; if (is_Node(first)) return null; if (first === undefined) return null; return first; } /** * Project an object-shaped value node to a plain JSON object. * * User-tag child wrappers become object keys. Internal `_hson_*` structural * children are skipped so VSN machinery does not leak into projected JSON. */ function object_node_to_value(node: HsonNode): JsonValue { const out: Record = {}; for (const child of node.$_content) { if (!is_Node(child)) continue; if (child.$_tag.startsWith(_HSON_)) continue; const payload = unwrap_value_payload(child); if (payload === undefined) continue; Object.defineProperty(out, child.$_tag, { value: node_to_json_value(payload), enumerable: true, writable: true, configurable: true, }); } return out; } /** * Project an array-shaped value node to a JSON array. * * Canonical admission has already ordered and validated every array item * wrapper, so projection follows physical order without reinterpreting index. */ function array_node_to_value(node: HsonNode): JsonValue[] { const values: JsonValue[] = []; for (const child of node.$_content) { if (!is_Node(child) || child.$_tag !== II_TAG) { throw new Error("LiveMap encountered a noncanonical array child."); } const payload = unwrap_value_payload(child); if (payload === undefined) { throw new Error("LiveMap encountered a noncanonical array item payload."); } values.push(node_to_json_value(payload)); } return values; } function materialize_projected_edit(edit: LiveMapProjectedEditResult): LiveMapEditResult { return { changed: edit.changed, prev: edit.prev === undefined ? undefined : materialize_projected_value(edit.prev), next: edit.next === undefined ? undefined : materialize_projected_value(edit.next), }; } function clone_hson_node(node: HsonNode): HsonNode { const partial: Partial = { $_tag: node.$_tag, $_content: node.$_content.map(clone_hson_content), }; if (node.$_attrs !== undefined) partial.$_attrs = clone_node(node.$_attrs); if (node.$_meta !== undefined) partial.$_meta = clone_node(node.$_meta); return CREATE_NODE(partial); } function clone_hson_content(value: HsonNode | Primitive): HsonNode | Primitive { return is_Node(value) ? clone_hson_node(value) : value; } /** Internal exact root installation that preserves the owned root object. */ export function overwrite_hson_node(target: HsonNode, source: HsonNode): void { target.$_tag = source.$_tag; if (source.$_attrs === undefined) delete target.$_attrs; else target.$_attrs = { ...source.$_attrs }; if (source.$_meta === undefined) delete target.$_meta; else target.$_meta = { ...source.$_meta }; target.$_content = source.$_content.map(clone_hson_content); }