Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
changeKind: internal
packages:
- "@typespec/mutator-framework"
---

Document mutation node identity, lifecycle propagation, and edge callback ownership.
6 changes: 6 additions & 0 deletions packages/mutator-framework/src/mutation-node/factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@ import { TupleMutationNode } from "./tuple.js";
import { UnionVariantMutationNode } from "./union-variant.js";
import { UnionMutationNode } from "./union.js";

/**
* Constructs a fresh node selected by the source type's kind, without caching.
* Use {@link MutationEngine.getMutationNode} to share nodes within an engine.
* Throws for unsupported kinds; creating a node does not traverse its references.
*/
export function mutationNodeFor<T extends Type>(
engine: MutationEngine<any>,
sourceType: T,
Expand Down Expand Up @@ -72,6 +77,7 @@ export function mutationNodeFor<T extends Type>(
}
}

/** Maps supported compiler type shapes to their corresponding concrete node classes. */
export type MutationNodeForType<T extends Type> = T extends Model
? ModelMutationNode
: T extends Interface
Expand Down
55 changes: 52 additions & 3 deletions packages/mutator-framework/src/mutation-node/mutation-edge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,23 @@ import type { MutationNodeForType } from "./factory.js";
import { MutationNode } from "./mutation-node.js";
import { traceEdge } from "./tracer.js";

/** Callbacks implementing how a head's type reference follows a tail's lifecycle. */
export interface MutationEdgeOptions<THead extends Type, TTail extends Type> {
onTailCreation?: (args: { tail: MutationNodeForType<TTail> }) => void;
onTailMutation: (args: { tail: MutationNodeForType<TTail> }) => void;
onTailDeletion: (args: { tail: MutationNodeForType<TTail> }) => void;
/** Called by {@link HalfEdge.setTail} before constructing the full edge. */
onTailCreation?: (args: {
/** Newly supplied dependency, available for establishing reverse connections. */
tail: MutationNodeForType<TTail>;
}) => void;
/** Updates the head's reference when the tail changes or is already mutated/synthetic at connection. */
onTailMutation: (args: {
/** Dependency whose current output should be reflected by the head. */
tail: MutationNodeForType<TTail>;
}) => void;
/** Removes or substitutes the head's reference when the tail is deleted. */
onTailDeletion: (args: {
/** Deleted dependency; its output is not a usable value for the original slot. */
tail: MutationNodeForType<TTail>;
}) => void;
/**
* Called when the tail node is replaced with a new node.
* @param args.oldTail - The original tail node that was replaced
Expand All @@ -16,24 +29,43 @@ export interface MutationEdgeOptions<THead extends Type, TTail extends Type> {
* If false, an edge already exists and should not be recreated.
*/
onTailReplaced: (args: {
/** Previous dependency, used to remove old names or other slot metadata. */
oldTail: MutationNodeForType<TTail>;
/** Replacement dependency, which may have a different type kind. */
newTail: MutationNodeForType<Type>;
/** Dependent node whose reference must be updated. */
head: MutationNodeForType<THead>;
/** Whether this callback must establish a new edge to keep receiving changes. */
reconnect: boolean;
}) => void;
/** Optionally transfers a dependency to a replacement head after the old edge is detached. */
onHeadReplaced?: (args: {
/** Head whose outgoing edge has been removed. */
oldHead: MutationNodeForType<THead>;
/** Replacement head, which may not support the same relationship. */
newHead: MutationNodeForType<Type>;
/** Dependency that the replacement head may reconnect to. */
tail: MutationNodeForType<TTail>;
}) => void;
}

/**
* Directed reference from a dependent head to a dependency tail.
* Tail lifecycle events invoke relationship-specific callbacks on the head.
* Replacement detaches the old edge; callbacks own any reconnection.
*/
export class MutationEdge<THead extends Type, TTail extends Type> {
/** Node containing the reference that depends on {@link tail}. */
public head: MutationNode<THead>;
/** Referenced node whose lifecycle this edge observes. */
public tail: MutationNode<TTail>;
#options: MutationEdgeOptions<THead, TTail>;
#deleted: boolean = false;

/**
* Registers both ends and synchronizes an already mutated, synthetic, or
* replaced tail. Unlike {@link HalfEdge.setTail}, does not call `onTailCreation`.
*/
constructor(
head: MutationNode<THead>,
tail: MutationNode<TTail>,
Expand Down Expand Up @@ -67,6 +99,7 @@ export class MutationEdge<THead extends Type, TTail extends Type> {
}
}

/** Constructs and registers a full edge with the same synchronization as the constructor. */
static create<THead extends Type, TTail extends Type>(
head: MutationNode<THead>,
tail: MutationNode<TTail>,
Expand All @@ -87,18 +120,24 @@ export class MutationEdge<THead extends Type, TTail extends Type> {
this.tail.deleteInEdge(this);
}

/** Dispatches the mutation callback unless the edge or its head has been deleted. */
tailMutated(): void {
if (this.#deleted || this.head.isDeleted) return;
traceEdge(this, "Tail mutated.");
this.#options.onTailMutation({ tail: this.tail as any });
}

/** Dispatches the deletion callback unless the edge or its head has been deleted. */
tailDeleted() {
if (this.#deleted || this.head.isDeleted) return;
traceEdge(this, "Tail deleted.");
this.#options.onTailDeletion({ tail: this.tail as any });
}

/**
* Detaches this edge and dispatches `onTailReplaced` with `reconnect: true`.
* The old tail defaults to this edge's tail; this method does not change the edge's endpoints.
*/
tailReplaced(newTail: MutationNode<Type>, oldTail?: MutationNode<TTail>) {
if (this.#deleted || this.head.isDeleted) return;
traceEdge(this, "Tail replaced with " + newTail.id);
Expand All @@ -115,6 +154,7 @@ export class MutationEdge<THead extends Type, TTail extends Type> {
});
}

/** Detaches this edge and lets `onHeadReplaced` optionally reconnect its dependency. */
headReplaced(newHead: MutationNode<Type>) {
if (this.#deleted || this.tail.isDeleted) return;
traceEdge(this, "Head replaced with " + newHead.id);
Expand All @@ -130,22 +170,31 @@ export class MutationEdge<THead extends Type, TTail extends Type> {
});
}

/** Formats the endpoint IDs for traces and debugging. */
toString() {
return `MutationEdge(head=${this.head.id}, tail=${this.tail.id})`;
}
}

/** Node-level reference whose dependency is supplied once, when it becomes available. */
export class HalfEdge<THead extends Type, TTail extends Type> {
/** Dependent node that starts the reference. */
public head: MutationNode<THead>;
/** Dependency, or `null` until {@link setTail} connects it. */
public tail: MutationNode<TTail> | null;
#options: MutationEdgeOptions<THead, TTail>;

/** Defers registration of the edge and its lifecycle callbacks until a tail is supplied. */
constructor(head: MutationNode<THead>, options: MutationEdgeOptions<THead, TTail>) {
this.head = head;
this.tail = null;
this.#options = options;
}

/**
* Calls `onTailCreation`, constructs a full edge, and returns it.
* Throws if this half-edge has already been completed.
*/
setTail(tail: MutationNode<TTail>): MutationEdge<THead, TTail> {
if (this.tail) {
throw new Error("HalfEdge already has a tail");
Expand Down
69 changes: 51 additions & 18 deletions packages/mutator-framework/src/mutation-node/mutation-node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,53 +5,64 @@ import { mutationNodeFor, type MutationNodeForType } from "./factory.js";
import type { MutationEdge } from "./mutation-edge.js";
import { traceNode } from "./tracer.js";

/**
* Mutation nodes represent a node in the type graph that can possibly be
* mutated. Each mutation node tracks the source type, the mutated type, and the
* edges coming from other mutation nodes which represent references to the
* source type for this node.
*
* The mutated type is initially a reference to the source type. When a node is
* mutated, the mutated type is initialized to a clone of the source type and
* any mutations are applied. Then all nodes which reference this node are also
* mutated, which will cause their mutated types to reference the mutated type
* of this node.
*
* Each node is unique based on the source type and an optional mutation key.
* The mutation key allows for multiple mutation nodes to exist for the same
* source type, which is useful when a type's mutation depends on its context
* such as how it is referenced.
*/

let nextId = 0;

/** Identity and origin metadata used when constructing a mutation node. */
export interface MutationNodeOptions {
/** Key distinguishing nodes for the same source type; defaults to `""`. */
mutationKey?: string;
/** Marks an introduced type so incoming connections propagate it even before cloning. */
isSynthetic?: boolean;
}

/**
* Tracks a source type and its output in a parallel type graph.
*
* The output initially aliases the source. The first {@link mutate} clones it
* and notifies incoming reference edges, allowing dependent nodes to update.
* Obtain nodes through {@link MutationEngine.getMutationNode} to share one node
* per source type identity and key within an engine; direct construction is not cached.
*/
export abstract class MutationNode<T extends Type> {
/** Type kind handled by this node's connection methods. */
abstract readonly kind: string;

/** Process-local sequential identifier used in diagnostic strings and traces. */
id = nextId++;
/** Original type used to initialize this node; not replaced when output changes. */
sourceType: T;
/** Current output: initially the source, then a clone, or `never` after deletion. */
mutatedType: T;
/** Whether this node has been cloned or designated as an already-mutated replacement. */
isMutated: boolean = false;
/** Whether connections must propagate this introduced type without waiting for a mutation. */
isSynthetic: boolean = false;
/** Whether {@link delete} has removed this node's contribution to its dependents. */
isDeleted: boolean = false;
/** Whether {@link replace} has redirected references to another node. */
isReplaced: boolean = false;
/** Successor created by {@link replace}, or `null` before replacement. */
replacementNode: MutationNodeForType<Type> | null = null;
/** If this node is a replacement for another node, this is the node it replaced */
replacedNode: MutationNodeForType<Type> | null = null;
/** References from dependent heads to this node as their tail. */
inEdges: Set<MutationEdge<Type, T>> = new Set();
/** References from this node as a head to the nodes it depends on. */
outEdges: Set<MutationEdge<T, Type>> = new Set();
/** Engine owning the node cache and typekit used for graph updates. */
engine: MutationEngine<any>;
/** Context key used with source type identity in the engine's node cache. */
mutationKey: string;
/** Type utilities from {@link engine}, used for cloning and fallback types. */
$: Typekit;
protected connected: boolean = false;

#whenMutatedCallbacks: ((mutatedType: Type | null) => void)[] = [];

/**
* Initializes an uncached node whose output aliases `sourceNode`.
* An omitted key is empty and an omitted synthetic flag is false.
*/
constructor(
subgraph: MutationEngine<any>,
sourceNode: T,
Expand All @@ -71,26 +82,42 @@ export abstract class MutationNode<T extends Type> {
traceNode(this, "Created.");
}

/** Registers an incoming reference; does not register it with the head. */
addInEdge(edge: MutationEdge<Type, T>) {
this.inEdges.add(edge);
}

/** Removes incoming-edge bookkeeping only; use {@link MutationEdge.delete} to detach both ends. */
deleteInEdge(edge: MutationEdge<Type, T>) {
this.inEdges.delete(edge);
}

/** Registers an outgoing dependency; does not register it with the tail. */
addOutEdge(edge: MutationEdge<T, Type>) {
this.outEdges.add(edge);
}

/** Removes outgoing-edge bookkeeping only; use {@link MutationEdge.delete} to detach both ends. */
deleteOutEdge(edge: MutationEdge<T, Type>) {
this.outEdges.delete(edge);
}

/**
* Registers a callback for the first clone and for deletion (`null`).
* Registration does not replay an earlier mutation. Later initializer calls
* and replacement do not invoke these callbacks.
*/
whenMutated(cb: (mutatedType: T | null) => void) {
this.#whenMutatedCallbacks.push(cb as any);
}

/**
* Clones on first use, runs the initializer, notifies callbacks and incoming
* edges, then finishes the clone through the typekit.
*
* Subsequent calls run only the initializer on the existing output. Deleted
* or replaced nodes ignore the call entirely.
*/
mutate(initializeMutation?: (type: T) => void) {
if (this.isDeleted || this.isReplaced) {
traceNode(this, `Already deleted/replaced, skipping mutation.`);
Expand Down Expand Up @@ -120,6 +147,11 @@ export abstract class MutationNode<T extends Type> {
this.$.type.finishType(this.mutatedType);
}

/**
* Marks this node deleted, calls mutation callbacks with `null`, sets its
* output to `never`, and notifies incoming edges. Each relationship decides
* whether to remove its slot, substitute a fallback, or delete its head.
*/
delete() {
traceNode(this, "Deleting.");

Expand Down Expand Up @@ -185,6 +217,7 @@ export abstract class MutationNode<T extends Type> {
return this.replacementNode;
}

/** Formats the source name or kind, mutation key, and local ID for debugging. */
toString() {
return `MutationNode(${"name" in this.sourceType && typeof this.sourceType.name === "string" ? this.sourceType.name : this.kind}, key=${this.mutationKey}, id=${this.id})`;
}
Expand Down
Loading