diff --git a/packages/freedom/specs/freedom-spec.md b/packages/freedom/specs/freedom-spec.md index 049916c..cd27acc 100644 --- a/packages/freedom/specs/freedom-spec.md +++ b/packages/freedom/specs/freedom-spec.md @@ -197,7 +197,7 @@ interface Node { set(key: string, value: JsonValue): void; update(key: string, fn: (prev: JsonValue | undefined) => JsonValue): void; unset(key: string): void; - createChild(name?: string): Node; + createChild(name?: string, options?: { before?: Node }): Node; sort(fn?: (a: Node, b: Node) => number): void; remove(): Promise; destroy(): Promise; @@ -250,9 +250,11 @@ visible to mutations on this node and its descendants, via context inheritance. ### 5.4 Lifecycle -N7. A node is created by `parent.createChild(name?)`: the constructor creates the -child's scope (a child of the parent's), the child is attached to the parent's -children, and it is returned synchronously. +N7. A node is created by `parent.createChild(name?, options?)`: the constructor +creates the child's scope (a child of the parent's), the child is attached to the +parent's children, and it is returned synchronously. If `options.before` is +given, the child is inserted immediately before that sibling in insertion order +instead of being appended (C14). N8. A node is destroyed by `node.remove()` (detach + teardown) or directly by `node.destroy()`, which disposes the node's scope — halting all descendant node @@ -306,6 +308,11 @@ fresh because it runs against current property values at iteration time. N21. Installing or clearing a sort function via `sort()` MUST emit a notification (§8), because the iteration order of children may have changed. +N22. A child MAY be inserted at a specific position via +`createChild(name, { before })` (C14). This sets its position in **insertion +order**; it does not bypass an active sort function (N20). The `options` object +is the extension point for future positioning hints (e.g. `after`, `at`). + ### 5.7 Node Data Node data is typed, symbol-keyed storage for non-serializable, private values @@ -366,7 +373,7 @@ interface Node { set(key: string, value: JsonValue): void; update(key: string, fn: (prev: JsonValue | undefined) => JsonValue): void; unset(key: string): void; - createChild(name?: string): Node; + createChild(name?: string, options?: { before?: Node }): Node; sort(fn?: (a: Node, b: Node) => number): void; remove(): Promise; destroy(): Promise; @@ -388,7 +395,7 @@ node.get(key): JsonValue | undefined node.set(key, value): void node.update(key, fn: (prev: JsonValue | undefined) => JsonValue): void node.unset(key): void -node.createChild(name?): Node +node.createChild(name?, options?: { before?: Node }): Node node.sort(fn?: (a: Node, b: Node) => number): void node.remove(): Promise node.destroy(): Promise @@ -448,6 +455,16 @@ C12. `createChild` attaches the child to this node's children and returns it C13. `createChild` marks the tree dirty (§8). +C14. `createChild(name?, options?)`: if `options.before` is provided, it MUST be a +current child of this node; the new child is inserted **immediately before** it in +insertion order. If `before` is not a current child, `createChild` throws. If +`before` is omitted, the child is appended. + +C15. The `before` position sets the child's place in **insertion order**. An +active sort function (N18) still reorders at read time, with insertion order as +the equal-compare tiebreaker (N20). `options` is an open object reserved for +future positioning hints (e.g. `after`, `at`). + **sort** C19. `sort(fn)` installs a sort function on the node. When `fn` is defined, diff --git a/packages/freedom/src/lib/node.ts b/packages/freedom/src/lib/node.ts index 4e8211f..5c6a894 100644 --- a/packages/freedom/src/lib/node.ts +++ b/packages/freedom/src/lib/node.ts @@ -7,7 +7,13 @@ import { type Scope, } from "effection"; import { createApi } from "effection/experimental"; -import type { JsonValue, Node, NodeData, NodeDataKey } from "./types.ts"; +import type { + CreateChildOptions, + JsonValue, + Node, + NodeData, + NodeDataKey, +} from "./types.ts"; import { TreeContext } from "./state.ts"; import { validateJsonValue } from "./validate.ts"; @@ -95,8 +101,8 @@ export class NodeImpl implements Node { NodeApi.invoke(this.scope, "unset", [this, key]); } - createChild(name = ""): Node { - return NodeApi.invoke(this.scope, "createChild", [this, name]); + createChild(name = "", options?: CreateChildOptions): Node { + return NodeApi.invoke(this.scope, "createChild", [this, name, options]); } sort(fn?: (a: Node, b: Node) => number): void { @@ -139,10 +145,26 @@ export const NodeApi = createApi("freedom:node", { node.scope.expect(TreeContext).markDirty(); } }, - createChild(node: NodeImpl, name: string): Node { + createChild(node: NodeImpl, name: string, options?: CreateChildOptions): Node { const state = node.scope.expect(TreeContext); const child = new NodeImpl(state.nextId(), name, node); - node._children.add(child); + const before = options?.before; + if (before) { + if (!node._children.has(before as NodeImpl)) { + throw new Error("createChild: `before` is not a child of this node"); + } + // Set has no positional insert, so rebuild it with `child` spliced in. + const reordered = new Set(); + for (const existing of node._children) { + if (existing === before) { + reordered.add(child); + } + reordered.add(existing); + } + node._children = reordered; + } else { + node._children.add(child); + } state.nodes.set(child.id, child); state.markDirty(); return child; diff --git a/packages/freedom/src/lib/types.ts b/packages/freedom/src/lib/types.ts index 241eb8a..859e654 100644 --- a/packages/freedom/src/lib/types.ts +++ b/packages/freedom/src/lib/types.ts @@ -26,6 +26,10 @@ export interface NodeData { expect(key: NodeDataKey): T; } +export interface CreateChildOptions { + before?: Node; +} + export interface Node { readonly id: string; readonly name: string; @@ -38,7 +42,7 @@ export interface Node { set(key: string, value: JsonValue): void; update(key: string, fn: (prev: JsonValue | undefined) => JsonValue): void; unset(key: string): void; - createChild(name?: string): Node; + createChild(name?: string, options?: CreateChildOptions): Node; sort(fn?: (a: Node, b: Node) => number): void; destroy(): Promise; remove(): Promise; diff --git a/packages/freedom/test/freedom.test.ts b/packages/freedom/test/freedom.test.ts index 2bbe7eb..8d74ee4 100644 --- a/packages/freedom/test/freedom.test.ts +++ b/packages/freedom/test/freedom.test.ts @@ -108,6 +108,47 @@ describe("Children and ordering", () => { root.destroy(); }); + it("createChild inserts before a sibling", () => { + const root = createRoot(); + root.node.createChild("A"); + const c = root.node.createChild("C"); + root.node.createChild("B", { before: c }); + expect([...root.node.children].map((n) => n.name)).toEqual(["A", "B", "C"]); + root.destroy(); + }); + + it("createChild before the first child inserts at the front", () => { + const root = createRoot(); + const a = root.node.createChild("A"); + root.node.createChild("Z", { before: a }); + expect([...root.node.children].map((n) => n.name)).toEqual(["Z", "A"]); + root.destroy(); + }); + + it("createChild throws when before is not a child", () => { + const root = createRoot(); + const a = root.node.createChild("A"); + const stranger = root.node.createChild("B").createChild("nested"); + expect(() => a.createChild("x", { before: stranger })).toThrow(); + root.destroy(); + }); + + it("before sets insertion order under an active sort tiebreaker", () => { + const root = createRoot(); + const a = root.node.createChild("A"); + const c = root.node.createChild("C"); + const b = root.node.createChild("B", { before: c }); + for (const n of [a, b, c]) { + n.set("priority", 1); + } + root.node.sort((x, y) => + (x.props["priority"] as number) - (y.props["priority"] as number) + ); + // all equal -> insertion order (with B spliced before C) is the tiebreaker + expect([...root.node.children].map((n) => n.name)).toEqual(["A", "B", "C"]); + root.destroy(); + }); + it("custom sort reorders children", () => { const root = createRoot(); const a = root.node.createChild("A");