@quokkaquery/graph 0.0.0-stage → 0.1.1

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/API.md ADDED
@@ -0,0 +1,100 @@
1
+ # @quokkaquery/graph — API Reference
2
+
3
+ Distilled API surface for agent prompts and quick reference. 7 exports from `src/index.ts`.
4
+
5
+ ## Types
6
+
7
+ ```typescript
8
+ export type NodeId = string | number
9
+
10
+ export interface GraphNode<T = unknown> {
11
+ id: NodeId
12
+ data: T
13
+ }
14
+
15
+ export interface Edge<E = unknown> {
16
+ from: NodeId
17
+ to: NodeId
18
+ weight: number
19
+ data: E
20
+ }
21
+
22
+ export interface GraphOptions {
23
+ directed?: boolean // default: true
24
+ }
25
+
26
+ export type Visitor<T> = (node: GraphNode<T>, depth: number) => void | false
27
+ export type NodePredicate<T> = (node: GraphNode<T>) => boolean
28
+ ```
29
+
30
+ ## Class
31
+
32
+ ```typescript
33
+ export class Graph<T = unknown, E = unknown> {
34
+ readonly directed: boolean
35
+
36
+ constructor(opts?: GraphOptions)
37
+
38
+ // ── Nodes ──────────────────────────────────────────
39
+ addNode(id: NodeId, data: T): this
40
+ getNode(id: NodeId): GraphNode<T> | undefined
41
+ hasNode(id: NodeId): boolean
42
+ removeNode(id: NodeId): boolean
43
+ get nodeCount(): number
44
+ nodeIds(): NodeId[]
45
+ nodeEntries(): GraphNode<T>[]
46
+
47
+ // ── Edges ──────────────────────────────────────────
48
+ addEdge(from: NodeId, to: NodeId, weight?: number, data?: E): this
49
+ getEdge(from: NodeId, to: NodeId): Edge<E> | undefined
50
+ hasEdge(from: NodeId, to: NodeId): boolean
51
+ removeEdge(from: NodeId, to: NodeId): boolean
52
+ get edgeCount(): number
53
+ edges(): Edge<E>[]
54
+ neighbors(id: NodeId): NodeId[]
55
+ outEdges(id: NodeId): Edge<E>[]
56
+ inEdges(id: NodeId): Edge<E>[]
57
+ outDegree(id: NodeId): number
58
+ inDegree(id: NodeId): number
59
+ degree(id: NodeId): number
60
+
61
+ // ── Node helpers ───────────────────────────────────
62
+ predecessors(id: NodeId): NodeId[]
63
+ successors(id: NodeId): NodeId[]
64
+ connectedNodes(id: NodeId): NodeId[]
65
+ isSource(id: NodeId): boolean // directed only
66
+ isSink(id: NodeId): boolean // directed only
67
+ isIsolated(id: NodeId): boolean
68
+ sources(): NodeId[] // directed only
69
+ sinks(): NodeId[] // directed only
70
+ isolatedNodes(): NodeId[]
71
+
72
+ // ── Reachability ───────────────────────────────────
73
+ reachableFrom(id: NodeId): Set<NodeId>
74
+ reachableTo(id: NodeId): Set<NodeId>
75
+
76
+ // ── Traversals ─────────────────────────────────────
77
+ bfs(start: NodeId, visitor: Visitor<T>): void
78
+ dfs(start: NodeId, visitor: Visitor<T>): void
79
+ findBfs(start: NodeId, predicate: NodePredicate<T>): GraphNode<T> | undefined
80
+ findDfs(start: NodeId, predicate: NodePredicate<T>): GraphNode<T> | undefined
81
+
82
+ // ── Algorithms ─────────────────────────────────────
83
+ topologicalSort(): NodeId[] // directed only, throws on cycle
84
+ hasCycle(): boolean
85
+ dijkstra(start: NodeId, end: NodeId): { distance: number; path: NodeId[] } | undefined
86
+ connectedComponents(): NodeId[][] // weakly-connected for directed
87
+
88
+ // ── Transforms ─────────────────────────────────────
89
+ mapNodes<U>(fn: (node: GraphNode<T>) => U): Graph<U, E>
90
+ subgraph(ids: Set<NodeId>): Graph<T, E>
91
+ }
92
+ ```
93
+
94
+ ## Notes
95
+
96
+ - Adjacency-list representation. Directed by default.
97
+ - `addEdge` auto-creates missing nodes. For undirected, both directions are stored internally.
98
+ - `edges()` deduplicates for undirected graphs.
99
+ - `reachableFrom` includes the start node only if a cycle leads back to it.
100
+ - Visitor callbacks can return `false` to stop traversal early.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 QuokkaQuery (https://quokkaquery.com)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,187 @@
1
- # Temporary Holding Version
1
+ # @quokkaquery/graph
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ > Built and maintained by [QuokkaQuery](https://quokkaquery.com). Source on [GitHub](https://github.com/Nodlume/graph).
4
+
5
+ Generic TypeScript graph library with adjacency-list representation. Supports directed and undirected graphs, weighted edges, traversals, and common graph algorithms.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install @quokkaquery/graph
11
+ ```
12
+
13
+ ## Quick Start
14
+
15
+ ```ts
16
+ import { Graph } from "@quokkaquery/graph";
17
+
18
+ const g = new Graph<string>({ directed: true });
19
+
20
+ g.addNode("a", "Alice")
21
+ .addNode("b", "Bob")
22
+ .addNode("c", "Carol");
23
+
24
+ g.addEdge("a", "b", 1);
25
+ g.addEdge("b", "c", 2);
26
+ g.addEdge("a", "c", 10);
27
+
28
+ // Shortest path
29
+ const result = g.dijkstra("a", "c");
30
+ // { distance: 3, path: ["a", "b", "c"] }
31
+ ```
32
+
33
+ ## API
34
+
35
+ ### Constructor
36
+
37
+ ```ts
38
+ new Graph<T, E>(opts?: { directed?: boolean })
39
+ ```
40
+
41
+ - `T` — node data type (default `unknown`)
42
+ - `E` — edge data type (default `unknown`)
43
+ - `directed` — defaults to `true`
44
+
45
+ ### Node Operations
46
+
47
+ | Method | Returns | Description |
48
+ |---|---|---|
49
+ | `addNode(id, data)` | `this` | Add a node (chainable) |
50
+ | `getNode(id)` | `GraphNode<T> \| undefined` | Get node by id |
51
+ | `hasNode(id)` | `boolean` | Check if node exists |
52
+ | `removeNode(id)` | `boolean` | Remove node and all its edges |
53
+ | `nodeCount` | `number` | Number of nodes |
54
+ | `nodeIds()` | `NodeId[]` | All node ids |
55
+ | `nodeEntries()` | `GraphNode<T>[]` | All nodes |
56
+
57
+ ### Edge Operations
58
+
59
+ | Method | Returns | Description |
60
+ |---|---|---|
61
+ | `addEdge(from, to, weight?, data?)` | `this` | Add an edge (chainable, default weight 1) |
62
+ | `getEdge(from, to)` | `Edge<E> \| undefined` | Get edge between two nodes |
63
+ | `hasEdge(from, to)` | `boolean` | Check if edge exists |
64
+ | `removeEdge(from, to)` | `boolean` | Remove an edge |
65
+ | `edgeCount` | `number` | Number of edges |
66
+ | `edges()` | `Edge<E>[]` | All edges (deduplicated for undirected) |
67
+
68
+ ### Neighbors & Degree
69
+
70
+ | Method | Returns | Description |
71
+ |---|---|---|
72
+ | `neighbors(id)` | `NodeId[]` | Outgoing neighbor ids |
73
+ | `outEdges(id)` | `Edge<E>[]` | Outgoing edges |
74
+ | `inEdges(id)` | `Edge<E>[]` | Incoming edges |
75
+ | `outDegree(id)` | `number` | Number of outgoing edges |
76
+ | `inDegree(id)` | `number` | Number of incoming edges |
77
+ | `degree(id)` | `number` | Total degree |
78
+ | `predecessors(id)` | `NodeId[]` | Nodes with edges pointing to this node |
79
+ | `successors(id)` | `NodeId[]` | Nodes this node points to |
80
+ | `connectedNodes(id)` | `NodeId[]` | Union of predecessors and successors |
81
+
82
+ ### Node Classification
83
+
84
+ | Method | Returns | Description |
85
+ |---|---|---|
86
+ | `isSource(id)` | `boolean` | In-degree 0 (directed only) |
87
+ | `isSink(id)` | `boolean` | Out-degree 0 (directed only) |
88
+ | `isIsolated(id)` | `boolean` | Degree 0 |
89
+ | `sources()` | `NodeId[]` | All source nodes |
90
+ | `sinks()` | `NodeId[]` | All sink nodes |
91
+ | `isolatedNodes()` | `NodeId[]` | All isolated nodes |
92
+
93
+ ### Reachability
94
+
95
+ | Method | Returns | Description |
96
+ |---|---|---|
97
+ | `reachableFrom(id)` | `Set<NodeId>` | All nodes reachable via outgoing edges |
98
+ | `reachableTo(id)` | `Set<NodeId>` | All nodes that can reach this node |
99
+
100
+ ### Traversals
101
+
102
+ | Method | Description |
103
+ |---|---|
104
+ | `bfs(start, visitor)` | Breadth-first traversal. Return `false` from visitor to stop early. |
105
+ | `dfs(start, visitor)` | Depth-first traversal. Return `false` from visitor to stop early. |
106
+ | `findBfs(start, predicate)` | Find first node matching predicate via BFS |
107
+ | `findDfs(start, predicate)` | Find first node matching predicate via DFS |
108
+
109
+ ### Algorithms
110
+
111
+ | Method | Description |
112
+ |---|---|
113
+ | `topologicalSort()` | Kahn's algorithm. Throws if graph has a cycle. Directed only. |
114
+ | `hasCycle()` | Cycle detection (DFS for directed, Union-Find for undirected) |
115
+ | `dijkstra(start, end)` | Shortest path. Returns `{ distance, path }` or `undefined`. |
116
+ | `connectedComponents()` | Connected (undirected) or weakly-connected (directed) components |
117
+
118
+ ### Utilities
119
+
120
+ | Method | Description |
121
+ |---|---|
122
+ | `mapNodes(fn)` | Transform node data, producing a new graph |
123
+ | `subgraph(ids)` | Extract a subgraph containing only the given node ids |
124
+
125
+ ## Types
126
+
127
+ ```ts
128
+ type NodeId = string | number;
129
+
130
+ interface GraphNode<T> { id: NodeId; data: T }
131
+
132
+ interface Edge<E> { from: NodeId; to: NodeId; weight: number; data: E }
133
+
134
+ type Visitor<T> = (node: GraphNode<T>, depth: number) => void | false;
135
+
136
+ type NodePredicate<T> = (node: GraphNode<T>) => boolean;
137
+ ```
138
+
139
+ ## Development
140
+
141
+ ```bash
142
+ npm test # run tests
143
+ npm run test:watch # watch mode
144
+ npm run build # compile TypeScript
145
+ ```
146
+
147
+ ## Gotchas
148
+
149
+ - **addEdge auto-creates nodes** with `undefined` data. If you need typed data, call `addNode` first.
150
+ - **Undirected edges** are stored as two directed entries internally. `edges()` deduplicates but `outEdges()`/`inEdges()` do not.
151
+ - **topologicalSort** throws `"Graph contains a cycle"` — catch it or check `hasCycle()` first.
152
+ - **dijkstra** uses an array-based priority queue — fine for moderate sizes, not for 100k+ node graphs.
153
+ - **reachableFrom** excludes the start node itself unless a cycle leads back to it.
154
+
155
+ ## Common Patterns
156
+
157
+ ### Dependency ordering
158
+
159
+ ```ts
160
+ const deps = new Graph<string>();
161
+ deps.addEdge("build", "lint");
162
+ deps.addEdge("test", "build");
163
+ const order = deps.topologicalSort(); // ["test", "build", "lint"]
164
+ ```
165
+
166
+ ### Shortest path
167
+
168
+ ```ts
169
+ const g = new Graph<string>();
170
+ g.addEdge("a", "b", 4).addEdge("b", "c", 2).addEdge("a", "c", 7);
171
+ const result = g.dijkstra("a", "c"); // { distance: 6, path: ["a", "b", "c"] }
172
+ ```
173
+
174
+ ### Filtering to a subgraph
175
+
176
+ ```ts
177
+ const important = new Set(g.nodeIds().filter(id => g.degree(id) > 3));
178
+ const sub = g.subgraph(important);
179
+ ```
180
+
181
+ ## License
182
+
183
+ MIT © [QuokkaQuery](https://quokkaquery.com)
184
+
185
+ ---
186
+
187
+ Part of the open-source tooling behind [QuokkaQuery](https://quokkaquery.com).
@@ -0,0 +1,142 @@
1
+ /**
2
+ * In-memory `Graph` class: node and edge storage, degree and reachability
3
+ * queries, BFS/DFS traversal, and topological sort, cycle detection,
4
+ * Dijkstra, connected components, mapping and subgraph extraction.
5
+ */
6
+ import type { NodeId, GraphNode, Edge, GraphOptions, Visitor, NodePredicate } from "./types.js";
7
+ /**
8
+ * Generic graph with adjacency-list representation.
9
+ * Supports directed and undirected graphs, weighted edges, and common traversals.
10
+ * Undirected edges are stored as a pair of mirrored directed edges, so
11
+ * per-node edge lists contain both directions.
12
+ *
13
+ * @typeParam T - Node data type
14
+ * @typeParam E - Edge data type
15
+ */
16
+ export declare class Graph<T = unknown, E = unknown> {
17
+ readonly directed: boolean;
18
+ private nodes;
19
+ private adjOut;
20
+ private adjIn;
21
+ /** Graphs are directed unless `opts.directed` is explicitly `false`. */
22
+ constructor(opts?: GraphOptions);
23
+ /** Adds or replaces a node's data; existing edges on `id` are kept. Chainable. */
24
+ addNode(id: NodeId, data: T): this;
25
+ getNode(id: NodeId): GraphNode<T> | undefined;
26
+ hasNode(id: NodeId): boolean;
27
+ /**
28
+ * Deletes a node and its edges; returns false if `id` was not a registered node.
29
+ * Only the first edge between `id` and each other node is removed from that
30
+ * node's lists, so parallel edges leave dangling entries behind.
31
+ */
32
+ removeNode(id: NodeId): boolean;
33
+ get nodeCount(): number;
34
+ nodeIds(): NodeId[];
35
+ nodeEntries(): GraphNode<T>[];
36
+ /**
37
+ * Appends an edge (weight defaults to 1); parallel edges are allowed. Endpoints
38
+ * not added via `addNode` get adjacency lists but are not registered nodes:
39
+ * they are absent from `nodeIds`/`getNode` and skipped by traversal visitors
40
+ * and `dijkstra`. Chainable.
41
+ */
42
+ addEdge(from: NodeId, to: NodeId, weight?: number, data?: E): this;
43
+ /** First stored edge from `from` to `to`; with parallel edges the others are not returned. */
44
+ getEdge(from: NodeId, to: NodeId): Edge<E> | undefined;
45
+ hasEdge(from: NodeId, to: NodeId): boolean;
46
+ /**
47
+ * Removes one `from`→`to` edge (and its mirror when undirected); returns false
48
+ * if none exists. Parallel edges must be removed one call at a time.
49
+ */
50
+ removeEdge(from: NodeId, to: NodeId): boolean;
51
+ /** Number of edges; undirected edges count once despite being stored twice. */
52
+ get edgeCount(): number;
53
+ /**
54
+ * All edges. For undirected graphs, one edge per node pair is returned in
55
+ * whichever direction was seen first, which also collapses parallel edges —
56
+ * so `mapNodes` and `subgraph` drop undirected parallel edges.
57
+ */
58
+ edges(): Edge<E>[];
59
+ /** Outgoing neighbors of a node. */
60
+ neighbors(id: NodeId): NodeId[];
61
+ /** Outgoing edges from a node. */
62
+ outEdges(id: NodeId): Edge<E>[];
63
+ /** Incoming edges to a node (always available, even for undirected). */
64
+ inEdges(id: NodeId): Edge<E>[];
65
+ /** Out-degree of a node. */
66
+ outDegree(id: NodeId): number;
67
+ /** In-degree of a node. */
68
+ inDegree(id: NodeId): number;
69
+ /** Total degree: in + out (for undirected, equals the number of incident edges). */
70
+ degree(id: NodeId): number;
71
+ /** Predecessor node ids — nodes with an edge pointing *to* this node. */
72
+ predecessors(id: NodeId): NodeId[];
73
+ /** Successor node ids — same as `neighbors`, nodes this node points *to*. */
74
+ successors(id: NodeId): NodeId[];
75
+ /**
76
+ * All directly connected node ids (union of predecessors and successors).
77
+ * For undirected graphs this equals `neighbors`. For directed graphs it
78
+ * includes both incoming and outgoing connections, deduplicated.
79
+ */
80
+ connectedNodes(id: NodeId): NodeId[];
81
+ /** Whether `id` is a source (no incoming edges). Directed only — always false for undirected. */
82
+ isSource(id: NodeId): boolean;
83
+ /** Whether `id` is a sink (no outgoing edges). Directed only — always false for undirected. */
84
+ isSink(id: NodeId): boolean;
85
+ /** Whether `id` has no edges at all. */
86
+ isIsolated(id: NodeId): boolean;
87
+ /** All source nodes (in-degree 0). Directed graphs only. */
88
+ sources(): NodeId[];
89
+ /** All sink nodes (out-degree 0). Directed graphs only. */
90
+ sinks(): NodeId[];
91
+ /** All isolated nodes (degree 0). */
92
+ isolatedNodes(): NodeId[];
93
+ /**
94
+ * All nodes reachable from `id` (transitive closure of successors).
95
+ * Does not include `id` itself unless there is a cycle back to it.
96
+ */
97
+ reachableFrom(id: NodeId): Set<NodeId>;
98
+ /**
99
+ * All nodes that can reach `id` (transitive closure of predecessors).
100
+ * Does not include `id` itself unless there is a cycle.
101
+ */
102
+ reachableTo(id: NodeId): Set<NodeId>;
103
+ /** Breadth-first traversal from a start node. */
104
+ bfs(start: NodeId, visitor: Visitor<T>): void;
105
+ /** Depth-first traversal from a start node (iterative). */
106
+ dfs(start: NodeId, visitor: Visitor<T>): void;
107
+ /** Find a node matching a predicate via BFS. */
108
+ findBfs(start: NodeId, predicate: NodePredicate<T>): GraphNode<T> | undefined;
109
+ /** Find a node matching a predicate via DFS. */
110
+ findDfs(start: NodeId, predicate: NodePredicate<T>): GraphNode<T> | undefined;
111
+ /**
112
+ * Topological sort (Kahn's algorithm). Returns node ids in topological order.
113
+ * Throws if the graph is undirected or has a cycle.
114
+ */
115
+ topologicalSort(): NodeId[];
116
+ /** Detect whether the graph contains a cycle. */
117
+ hasCycle(): boolean;
118
+ /**
119
+ * Dijkstra's shortest path from `start` to `end`.
120
+ * Returns `{ distance, path }` or `undefined` if unreachable.
121
+ * Edges must have non-negative weights. `end` must be a registered node;
122
+ * otherwise the result is a one-element path with an undefined distance
123
+ * rather than `undefined`.
124
+ */
125
+ dijkstra(start: NodeId, end: NodeId): {
126
+ distance: number;
127
+ path: NodeId[];
128
+ } | undefined;
129
+ /**
130
+ * Connected components (undirected) or weakly-connected components (directed).
131
+ * Returns an array of node-id arrays, one per component.
132
+ */
133
+ connectedComponents(): NodeId[][];
134
+ /**
135
+ * Map over node data, producing a new graph with transformed data.
136
+ * Edges and structure are preserved.
137
+ */
138
+ mapNodes<U>(fn: (node: GraphNode<T>) => U): Graph<U, E>;
139
+ /** Create a subgraph containing only the given node ids. */
140
+ subgraph(ids: Set<NodeId>): Graph<T, E>;
141
+ private ensureNode;
142
+ }
package/dist/graph.js ADDED
@@ -0,0 +1,535 @@
1
+ /**
2
+ * Generic graph with adjacency-list representation.
3
+ * Supports directed and undirected graphs, weighted edges, and common traversals.
4
+ * Undirected edges are stored as a pair of mirrored directed edges, so
5
+ * per-node edge lists contain both directions.
6
+ *
7
+ * @typeParam T - Node data type
8
+ * @typeParam E - Edge data type
9
+ */
10
+ export class Graph {
11
+ directed;
12
+ nodes = new Map();
13
+ adjOut = new Map(); // outgoing edges
14
+ adjIn = new Map(); // incoming edges (directed only, for convenience)
15
+ /** Graphs are directed unless `opts.directed` is explicitly `false`. */
16
+ constructor(opts = {}) {
17
+ this.directed = opts.directed ?? true;
18
+ }
19
+ // ── Node operations ──────────────────────────────────────────────
20
+ /** Adds or replaces a node's data; existing edges on `id` are kept. Chainable. */
21
+ addNode(id, data) {
22
+ this.nodes.set(id, { id, data });
23
+ if (!this.adjOut.has(id))
24
+ this.adjOut.set(id, []);
25
+ if (!this.adjIn.has(id))
26
+ this.adjIn.set(id, []);
27
+ return this;
28
+ }
29
+ getNode(id) {
30
+ return this.nodes.get(id);
31
+ }
32
+ hasNode(id) {
33
+ return this.nodes.has(id);
34
+ }
35
+ /**
36
+ * Deletes a node and its edges; returns false if `id` was not a registered node.
37
+ * Only the first edge between `id` and each other node is removed from that
38
+ * node's lists, so parallel edges leave dangling entries behind.
39
+ */
40
+ removeNode(id) {
41
+ if (!this.nodes.has(id))
42
+ return false;
43
+ this.nodes.delete(id);
44
+ // Remove all edges touching this node
45
+ this.adjOut.delete(id);
46
+ this.adjIn.delete(id);
47
+ for (const [, edges] of this.adjOut) {
48
+ const idx = edges.findIndex((e) => e.to === id);
49
+ if (idx !== -1)
50
+ edges.splice(idx, 1);
51
+ }
52
+ for (const [, edges] of this.adjIn) {
53
+ const idx = edges.findIndex((e) => e.from === id);
54
+ if (idx !== -1)
55
+ edges.splice(idx, 1);
56
+ }
57
+ return true;
58
+ }
59
+ get nodeCount() {
60
+ return this.nodes.size;
61
+ }
62
+ nodeIds() {
63
+ return [...this.nodes.keys()];
64
+ }
65
+ nodeEntries() {
66
+ return [...this.nodes.values()];
67
+ }
68
+ // ── Edge operations ──────────────────────────────────────────────
69
+ /**
70
+ * Appends an edge (weight defaults to 1); parallel edges are allowed. Endpoints
71
+ * not added via `addNode` get adjacency lists but are not registered nodes:
72
+ * they are absent from `nodeIds`/`getNode` and skipped by traversal visitors
73
+ * and `dijkstra`. Chainable.
74
+ */
75
+ addEdge(from, to, weight = 1, data) {
76
+ this.ensureNode(from);
77
+ this.ensureNode(to);
78
+ const edge = { from, to, weight, data: data };
79
+ this.adjOut.get(from).push(edge);
80
+ this.adjIn.get(to).push(edge);
81
+ if (!this.directed) {
82
+ const reverse = { from: to, to: from, weight, data: data };
83
+ this.adjOut.get(to).push(reverse);
84
+ this.adjIn.get(from).push(reverse);
85
+ }
86
+ return this;
87
+ }
88
+ /** First stored edge from `from` to `to`; with parallel edges the others are not returned. */
89
+ getEdge(from, to) {
90
+ return this.adjOut.get(from)?.find((e) => e.to === to);
91
+ }
92
+ hasEdge(from, to) {
93
+ return this.getEdge(from, to) !== undefined;
94
+ }
95
+ /**
96
+ * Removes one `from`→`to` edge (and its mirror when undirected); returns false
97
+ * if none exists. Parallel edges must be removed one call at a time.
98
+ */
99
+ removeEdge(from, to) {
100
+ const out = this.adjOut.get(from);
101
+ if (!out)
102
+ return false;
103
+ const idx = out.findIndex((e) => e.to === to);
104
+ if (idx === -1)
105
+ return false;
106
+ out.splice(idx, 1);
107
+ const inEdges = this.adjIn.get(to);
108
+ if (inEdges) {
109
+ const iIdx = inEdges.findIndex((e) => e.from === from);
110
+ if (iIdx !== -1)
111
+ inEdges.splice(iIdx, 1);
112
+ }
113
+ if (!this.directed) {
114
+ const revOut = this.adjOut.get(to);
115
+ if (revOut) {
116
+ const rIdx = revOut.findIndex((e) => e.to === from);
117
+ if (rIdx !== -1)
118
+ revOut.splice(rIdx, 1);
119
+ }
120
+ const revIn = this.adjIn.get(from);
121
+ if (revIn) {
122
+ const rIdx = revIn.findIndex((e) => e.from === to);
123
+ if (rIdx !== -1)
124
+ revIn.splice(rIdx, 1);
125
+ }
126
+ }
127
+ return true;
128
+ }
129
+ /** Number of edges; undirected edges count once despite being stored twice. */
130
+ get edgeCount() {
131
+ let count = 0;
132
+ for (const [, edges] of this.adjOut)
133
+ count += edges.length;
134
+ return this.directed ? count : count / 2;
135
+ }
136
+ /**
137
+ * All edges. For undirected graphs, one edge per node pair is returned in
138
+ * whichever direction was seen first, which also collapses parallel edges —
139
+ * so `mapNodes` and `subgraph` drop undirected parallel edges.
140
+ */
141
+ edges() {
142
+ const all = [];
143
+ for (const [, edges] of this.adjOut)
144
+ all.push(...edges);
145
+ if (!this.directed) {
146
+ // Deduplicate: keep the first-seen direction of each node pair
147
+ const seen = new Set();
148
+ return all.filter((e) => {
149
+ const key = `${e.from}->${e.to}`;
150
+ const rev = `${e.to}->${e.from}`;
151
+ if (seen.has(key) || seen.has(rev))
152
+ return false;
153
+ seen.add(key);
154
+ return true;
155
+ });
156
+ }
157
+ return all;
158
+ }
159
+ /** Outgoing neighbors of a node. */
160
+ neighbors(id) {
161
+ return (this.adjOut.get(id) ?? []).map((e) => e.to);
162
+ }
163
+ /** Outgoing edges from a node. */
164
+ outEdges(id) {
165
+ return this.adjOut.get(id) ?? [];
166
+ }
167
+ /** Incoming edges to a node (always available, even for undirected). */
168
+ inEdges(id) {
169
+ return this.adjIn.get(id) ?? [];
170
+ }
171
+ /** Out-degree of a node. */
172
+ outDegree(id) {
173
+ return (this.adjOut.get(id) ?? []).length;
174
+ }
175
+ /** In-degree of a node. */
176
+ inDegree(id) {
177
+ return (this.adjIn.get(id) ?? []).length;
178
+ }
179
+ /** Total degree: in + out (for undirected, equals the number of incident edges). */
180
+ degree(id) {
181
+ return this.directed
182
+ ? this.inDegree(id) + this.outDegree(id)
183
+ : this.outDegree(id); // undirected stores both directions, so outDegree already counts all
184
+ }
185
+ // ── Node-level helpers (for custom traversals) ───────────────────
186
+ /** Predecessor node ids — nodes with an edge pointing *to* this node. */
187
+ predecessors(id) {
188
+ return (this.adjIn.get(id) ?? []).map((e) => e.from);
189
+ }
190
+ /** Successor node ids — same as `neighbors`, nodes this node points *to*. */
191
+ successors(id) {
192
+ return this.neighbors(id);
193
+ }
194
+ /**
195
+ * All directly connected node ids (union of predecessors and successors).
196
+ * For undirected graphs this equals `neighbors`. For directed graphs it
197
+ * includes both incoming and outgoing connections, deduplicated.
198
+ */
199
+ connectedNodes(id) {
200
+ if (!this.directed)
201
+ return this.neighbors(id);
202
+ const set = new Set();
203
+ for (const e of this.adjOut.get(id) ?? [])
204
+ set.add(e.to);
205
+ for (const e of this.adjIn.get(id) ?? [])
206
+ set.add(e.from);
207
+ return [...set];
208
+ }
209
+ /** Whether `id` is a source (no incoming edges). Directed only — always false for undirected. */
210
+ isSource(id) {
211
+ return this.directed && this.inDegree(id) === 0 && this.hasNode(id);
212
+ }
213
+ /** Whether `id` is a sink (no outgoing edges). Directed only — always false for undirected. */
214
+ isSink(id) {
215
+ return this.directed && this.outDegree(id) === 0 && this.hasNode(id);
216
+ }
217
+ /** Whether `id` has no edges at all. */
218
+ isIsolated(id) {
219
+ return this.degree(id) === 0 && this.hasNode(id);
220
+ }
221
+ /** All source nodes (in-degree 0). Directed graphs only. */
222
+ sources() {
223
+ if (!this.directed)
224
+ return [];
225
+ return this.nodeIds().filter((id) => this.inDegree(id) === 0);
226
+ }
227
+ /** All sink nodes (out-degree 0). Directed graphs only. */
228
+ sinks() {
229
+ if (!this.directed)
230
+ return [];
231
+ return this.nodeIds().filter((id) => this.outDegree(id) === 0);
232
+ }
233
+ /** All isolated nodes (degree 0). */
234
+ isolatedNodes() {
235
+ return this.nodeIds().filter((id) => this.degree(id) === 0);
236
+ }
237
+ /**
238
+ * All nodes reachable from `id` (transitive closure of successors).
239
+ * Does not include `id` itself unless there is a cycle back to it.
240
+ */
241
+ reachableFrom(id) {
242
+ const reached = new Set();
243
+ const stack = [...this.neighbors(id)];
244
+ while (stack.length > 0) {
245
+ const cur = stack.pop();
246
+ if (reached.has(cur))
247
+ continue;
248
+ reached.add(cur);
249
+ for (const next of this.neighbors(cur)) {
250
+ if (!reached.has(next))
251
+ stack.push(next);
252
+ }
253
+ }
254
+ return reached;
255
+ }
256
+ /**
257
+ * All nodes that can reach `id` (transitive closure of predecessors).
258
+ * Does not include `id` itself unless there is a cycle.
259
+ */
260
+ reachableTo(id) {
261
+ const reached = new Set();
262
+ const stack = [...this.predecessors(id)];
263
+ while (stack.length > 0) {
264
+ const cur = stack.pop();
265
+ if (reached.has(cur))
266
+ continue;
267
+ reached.add(cur);
268
+ for (const prev of this.predecessors(cur)) {
269
+ if (!reached.has(prev))
270
+ stack.push(prev);
271
+ }
272
+ }
273
+ return reached;
274
+ }
275
+ // ── Traversals ───────────────────────────────────────────────────
276
+ /** Breadth-first traversal from a start node. */
277
+ bfs(start, visitor) {
278
+ const visited = new Set();
279
+ const queue = [{ id: start, depth: 0 }];
280
+ visited.add(start);
281
+ while (queue.length > 0) {
282
+ const { id, depth } = queue.shift();
283
+ const node = this.nodes.get(id);
284
+ if (!node)
285
+ continue;
286
+ if (visitor(node, depth) === false)
287
+ return;
288
+ for (const nId of this.neighbors(id)) {
289
+ if (!visited.has(nId)) {
290
+ visited.add(nId);
291
+ queue.push({ id: nId, depth: depth + 1 });
292
+ }
293
+ }
294
+ }
295
+ }
296
+ /** Depth-first traversal from a start node (iterative). */
297
+ dfs(start, visitor) {
298
+ const visited = new Set();
299
+ const stack = [{ id: start, depth: 0 }];
300
+ while (stack.length > 0) {
301
+ const { id, depth } = stack.pop();
302
+ if (visited.has(id))
303
+ continue;
304
+ visited.add(id);
305
+ const node = this.nodes.get(id);
306
+ if (!node)
307
+ continue;
308
+ if (visitor(node, depth) === false)
309
+ return;
310
+ // Push neighbors in reverse so the first neighbor is visited first
311
+ const nbrs = this.neighbors(id);
312
+ for (let i = nbrs.length - 1; i >= 0; i--) {
313
+ if (!visited.has(nbrs[i]))
314
+ stack.push({ id: nbrs[i], depth: depth + 1 });
315
+ }
316
+ }
317
+ }
318
+ /** Find a node matching a predicate via BFS. */
319
+ findBfs(start, predicate) {
320
+ let found;
321
+ this.bfs(start, (node) => {
322
+ if (predicate(node)) {
323
+ found = node;
324
+ return false;
325
+ }
326
+ });
327
+ return found;
328
+ }
329
+ /** Find a node matching a predicate via DFS. */
330
+ findDfs(start, predicate) {
331
+ let found;
332
+ this.dfs(start, (node) => {
333
+ if (predicate(node)) {
334
+ found = node;
335
+ return false;
336
+ }
337
+ });
338
+ return found;
339
+ }
340
+ // ── Algorithms ───────────────────────────────────────────────────
341
+ /**
342
+ * Topological sort (Kahn's algorithm). Returns node ids in topological order.
343
+ * Throws if the graph is undirected or has a cycle.
344
+ */
345
+ topologicalSort() {
346
+ if (!this.directed)
347
+ throw new Error("Topological sort requires a directed graph");
348
+ const inDeg = new Map();
349
+ for (const id of this.nodes.keys())
350
+ inDeg.set(id, 0);
351
+ for (const [, edges] of this.adjOut) {
352
+ for (const e of edges)
353
+ inDeg.set(e.to, (inDeg.get(e.to) ?? 0) + 1);
354
+ }
355
+ const queue = [];
356
+ for (const [id, deg] of inDeg)
357
+ if (deg === 0)
358
+ queue.push(id);
359
+ const result = [];
360
+ while (queue.length > 0) {
361
+ const id = queue.shift();
362
+ result.push(id);
363
+ for (const e of this.adjOut.get(id) ?? []) {
364
+ const newDeg = inDeg.get(e.to) - 1;
365
+ inDeg.set(e.to, newDeg);
366
+ if (newDeg === 0)
367
+ queue.push(e.to);
368
+ }
369
+ }
370
+ if (result.length !== this.nodes.size)
371
+ throw new Error("Graph contains a cycle");
372
+ return result;
373
+ }
374
+ /** Detect whether the graph contains a cycle. */
375
+ hasCycle() {
376
+ if (this.directed) {
377
+ // DFS-based: white(0)/gray(1)/black(2)
378
+ const color = new Map();
379
+ for (const id of this.nodes.keys())
380
+ color.set(id, 0);
381
+ const dfs = (id) => {
382
+ color.set(id, 1);
383
+ for (const e of this.adjOut.get(id) ?? []) {
384
+ if (color.get(e.to) === 1)
385
+ return true;
386
+ if (color.get(e.to) === 0 && dfs(e.to))
387
+ return true;
388
+ }
389
+ color.set(id, 2);
390
+ return false;
391
+ };
392
+ for (const id of this.nodes.keys()) {
393
+ if (color.get(id) === 0 && dfs(id))
394
+ return true;
395
+ }
396
+ return false;
397
+ }
398
+ else {
399
+ // Union-Find for undirected
400
+ const parent = new Map();
401
+ const find = (x) => {
402
+ while (parent.get(x) !== x) {
403
+ parent.set(x, parent.get(parent.get(x)));
404
+ x = parent.get(x);
405
+ }
406
+ return x;
407
+ };
408
+ for (const id of this.nodes.keys())
409
+ parent.set(id, id);
410
+ for (const edge of this.edges()) {
411
+ const rootA = find(edge.from);
412
+ const rootB = find(edge.to);
413
+ if (rootA === rootB)
414
+ return true;
415
+ parent.set(rootA, rootB);
416
+ }
417
+ return false;
418
+ }
419
+ }
420
+ /**
421
+ * Dijkstra's shortest path from `start` to `end`.
422
+ * Returns `{ distance, path }` or `undefined` if unreachable.
423
+ * Edges must have non-negative weights. `end` must be a registered node;
424
+ * otherwise the result is a one-element path with an undefined distance
425
+ * rather than `undefined`.
426
+ */
427
+ dijkstra(start, end) {
428
+ const dist = new Map();
429
+ const prev = new Map();
430
+ const visited = new Set();
431
+ // Simple priority queue (array-based — fine for moderate graph sizes)
432
+ const pq = [];
433
+ for (const id of this.nodes.keys())
434
+ dist.set(id, Infinity);
435
+ dist.set(start, 0);
436
+ prev.set(start, null);
437
+ pq.push({ id: start, d: 0 });
438
+ while (pq.length > 0) {
439
+ pq.sort((a, b) => a.d - b.d);
440
+ const { id } = pq.shift();
441
+ if (visited.has(id))
442
+ continue;
443
+ visited.add(id);
444
+ if (id === end)
445
+ break;
446
+ for (const e of this.adjOut.get(id) ?? []) {
447
+ const alt = dist.get(id) + e.weight;
448
+ if (alt < dist.get(e.to)) {
449
+ dist.set(e.to, alt);
450
+ prev.set(e.to, id);
451
+ pq.push({ id: e.to, d: alt });
452
+ }
453
+ }
454
+ }
455
+ if (dist.get(end) === Infinity)
456
+ return undefined;
457
+ const path = [];
458
+ let cur = end;
459
+ while (cur !== null) {
460
+ path.unshift(cur);
461
+ cur = prev.get(cur) ?? null;
462
+ }
463
+ return { distance: dist.get(end), path };
464
+ }
465
+ /**
466
+ * Connected components (undirected) or weakly-connected components (directed).
467
+ * Returns an array of node-id arrays, one per component.
468
+ */
469
+ connectedComponents() {
470
+ const visited = new Set();
471
+ const components = [];
472
+ for (const id of this.nodes.keys()) {
473
+ if (visited.has(id))
474
+ continue;
475
+ const component = [];
476
+ // BFS ignoring direction
477
+ const queue = [id];
478
+ visited.add(id);
479
+ while (queue.length > 0) {
480
+ const cur = queue.shift();
481
+ component.push(cur);
482
+ for (const e of this.adjOut.get(cur) ?? []) {
483
+ if (!visited.has(e.to)) {
484
+ visited.add(e.to);
485
+ queue.push(e.to);
486
+ }
487
+ }
488
+ if (this.directed) {
489
+ for (const e of this.adjIn.get(cur) ?? []) {
490
+ if (!visited.has(e.from)) {
491
+ visited.add(e.from);
492
+ queue.push(e.from);
493
+ }
494
+ }
495
+ }
496
+ }
497
+ components.push(component);
498
+ }
499
+ return components;
500
+ }
501
+ /**
502
+ * Map over node data, producing a new graph with transformed data.
503
+ * Edges and structure are preserved.
504
+ */
505
+ mapNodes(fn) {
506
+ const g = new Graph({ directed: this.directed });
507
+ for (const node of this.nodes.values())
508
+ g.addNode(node.id, fn(node));
509
+ for (const edge of this.edges())
510
+ g.addEdge(edge.from, edge.to, edge.weight, edge.data);
511
+ return g;
512
+ }
513
+ /** Create a subgraph containing only the given node ids. */
514
+ subgraph(ids) {
515
+ const g = new Graph({ directed: this.directed });
516
+ for (const id of ids) {
517
+ const node = this.nodes.get(id);
518
+ if (node)
519
+ g.addNode(id, node.data);
520
+ }
521
+ for (const edge of this.edges()) {
522
+ if (ids.has(edge.from) && ids.has(edge.to)) {
523
+ g.addEdge(edge.from, edge.to, edge.weight, edge.data);
524
+ }
525
+ }
526
+ return g;
527
+ }
528
+ // ── Internal ─────────────────────────────────────────────────────
529
+ ensureNode(id) {
530
+ if (!this.adjOut.has(id)) {
531
+ this.adjOut.set(id, []);
532
+ this.adjIn.set(id, []);
533
+ }
534
+ }
535
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Public entry point for the graph package: re-exports the `Graph` class and
3
+ * its supporting types, so consumers never import from internal module paths.
4
+ * The types are exported with `export type`, so they disappear at compile time
5
+ * and only `Graph` remains as a runtime export.
6
+ */
7
+ export { Graph } from "./graph.js";
8
+ export type { NodeId, GraphNode, Edge, GraphOptions, Visitor, NodePredicate, } from "./types.js";
package/dist/index.js ADDED
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Public entry point for the graph package: re-exports the `Graph` class and
3
+ * its supporting types, so consumers never import from internal module paths.
4
+ * The types are exported with `export type`, so they disappear at compile time
5
+ * and only `Graph` remains as a runtime export.
6
+ */
7
+ export { Graph } from "./graph.js";
@@ -0,0 +1,22 @@
1
+ /** Unique identifier for a node — typically a string or number. */
2
+ export type NodeId = string | number;
3
+ /** A node in the graph, holding an id and arbitrary data. */
4
+ export interface GraphNode<T = unknown> {
5
+ id: NodeId;
6
+ data: T;
7
+ }
8
+ /** An edge connecting two nodes, with optional weight and metadata. */
9
+ export interface Edge<E = unknown> {
10
+ from: NodeId;
11
+ to: NodeId;
12
+ weight: number;
13
+ data: E;
14
+ }
15
+ /** Options when constructing a graph. */
16
+ export interface GraphOptions {
17
+ directed?: boolean;
18
+ }
19
+ /** Visitor callback for traversals. Return `false` to stop early. */
20
+ export type Visitor<T> = (node: GraphNode<T>, depth: number) => void | false;
21
+ /** Filter predicate for search methods. */
22
+ export type NodePredicate<T> = (node: GraphNode<T>) => boolean;
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json CHANGED
@@ -1,6 +1,58 @@
1
1
  {
2
2
  "name": "@quokkaquery/graph",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "Generic reusable graph library with structure representation and traversal methods",
5
+ "keywords": [
6
+ "graph",
7
+ "data-structure",
8
+ "traversal",
9
+ "bfs",
10
+ "dfs",
11
+ "topological-sort",
12
+ "typescript"
13
+ ],
14
+ "homepage": "https://quokkaquery.com",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/Nodlume/graph.git"
18
+ },
19
+ "bugs": {
20
+ "url": "https://github.com/Nodlume/graph/issues"
21
+ },
22
+ "author": {
23
+ "name": "QuokkaQuery",
24
+ "url": "https://quokkaquery.com"
25
+ },
26
+ "license": "MIT",
27
+ "type": "module",
28
+ "main": "dist/index.js",
29
+ "types": "dist/index.d.ts",
30
+ "exports": {
31
+ ".": {
32
+ "types": "./dist/index.d.ts",
33
+ "import": "./dist/index.js",
34
+ "require": "./dist/index.js",
35
+ "default": "./dist/index.js"
36
+ }
37
+ },
38
+ "files": [
39
+ "dist",
40
+ "README.md",
41
+ "API.md",
42
+ "LICENSE"
43
+ ],
44
+ "sideEffects": false,
45
+ "publishConfig": {
46
+ "access": "public"
47
+ },
48
+ "scripts": {
49
+ "build": "tsc",
50
+ "test": "vitest run",
51
+ "test:watch": "vitest",
52
+ "prepublishOnly": "npm run build"
53
+ },
54
+ "devDependencies": {
55
+ "typescript": "^5.7.0",
56
+ "vitest": "^3.0.0"
57
+ }
58
+ }